RISC-V: A Bare-metal Introduction using C++. Machine Mode Timer.
This is the sixth post in a series, about the RISC-V machine mode timer and timing keeping using the C++ std::chrono
library.
How does RISC-V keep time? How can we perform a periodic task with no operating system?
You may take for granted that you can simply ask the operating system to sleep and wake you up in a second. If you have programmed bare-metal systems, you’ll understand it’s not as straightforward as calling sleep()
.
The Machine Level ISA Timer
The RISC-V machine level ISA defines a real-time counter. It is defined as two MMIO system registers mtime and mtimer .
To get an interrupt one second from now, you simply need to set mtimecmp
to mtime
+ 1 second.
The programming model is quite simple — when mtimecmp
≥mtime
you get an mti
interrupt. The mtime
register is a counter that increases monotonically - forever. The mtimecmp
is continuously compared to it. As both registers are 64 bits there is no concern about overflow.
While most system registers are accessed via special instructions,mtime
and mtimecmp
, are accessed via MMIO (memory-mapped IO). This is because the mtime
register depends on a global real-time clock and may need to be placed on a bus shared by many cores.
There is one remaining question, how do we know what 1-second corresponds to in mtime
counts?
Timekeeping in Modern C++
Modern C++ includes the std::chrono
library, and std::chrono::literals
that allow us to think in terms of real time, not machine time. For embedded systems, time is a first-order concern, a benefit of C++ is that it makes it a standard part of the language.
Can we have a driver that simply lets as program “give me an interrupt in one second”?
Let’s look at the driver timer.hpp. We can start by defining the period of the mtime
clock in C++ terms, via std::chrono::duration
. This is a template as the mtime
clock period is defined by the implementation. (For a SiFive device we can find the clock period and other parameters in the BSP device tree.)
The driver::timer::timer_ticks
declaration is the period of mtime
. It defines the period as a ratio.
namespace driver {
struct default_timer_config {
static constexpr unsigned int MTIME_FREQ_HZ=32768;
};
template<class CONFIG=default_timer_config> class timer {
/** Duration of each timer tick */
using timer_ticks = std::chrono::duration<int,
std::ratio<1, CONFIG::MTIME_FREQ_HZ>>;
}
}
Next, how can we convert these timer ticks to another time base? std::chrono::duration_cast
does the job. The expression std::chrono::duration_cast<timer_ticks>(time_offset)
gives the ratio of the number of seconds to clocks in one second.
If we have a timer value from mtime
and want to convert to microseconds, then we use:
uint64_t value_from_mtime = ...;
auto value_in_ms =
std::chrono::duration_cast<std::chrono::microseconds>(
driver::timer::timer_ticks(value_from_mtime));
Alternatively, to convert from microseconds to a hardware timer value for mtimecmp
then we use:
auto time_offset = std::chrono::microseconds(???);
uint64_t value_of_mtimecmp = std::chrono::duration_cast<timer_ticks>
(time_offset).count();
It’s all computed at compile-time, so no run-time cost is incurred.
Reading/Writing MMIO Registers in C++
There is not much difference between accessing MMIO registers in C, and C++. One advantage C++ has is templates. As RISC-V’s timer registers are not at a fixed address (absolute or relative to each other), re-usable code should be parameterized. Here that is done via template parameters.
struct mtimer_address_spec {
static constexpr std::uintptr_t
MTIMECMP_ADDR = 0x2000000 + 0x4000;
static constexpr std::uintptr_t
MTIME_ADDR = 0x2000000 + 0xBFF8;
};template<class ADDRESS_SPEC=mtimer_address_spec>
void set_raw_time_cmp(uint64_t clock_offset) {
// Single bus access
auto mtimecmp = reinterpret_cast<volatile std::uint64_t *>
(ADDRESS_SPEC::MTIMECMP_ADDR);
*mtimecmp = *mtimecmp + clock_offset;
}
In C we could use a structure to define the location of each register with a run time cost, or a set of pre-processor macros to make this zero-cost, however, in C++ we can pass a structure via a template parameter at zero cost.
Conclusion
The timer driver covers a few core topics in bare-metal programming and how C++ can provide an advantage.
- MMIO access and static polymorphism.
- Hardware real-time clocks.
- Converting clock frequencies and periods to human-readable units.
- Configuring drivers via templates and
constexpr
.
64 Bit Registers Access on a 32 Bit Bus
There is a small complication accessing timer registers, they are 64 bits wide and time tends to update constantly while our program is executing. On a 32 bit system, we can only access 1/2 of the register at a time.
Imagine this sequence.
- The
mtime
is0x0000_0000_FFFF_FFFF
. - We read the top 32 bits,
0x0000_0000
- We save this into our register t0.
- The real time clock ticks.
- The
mtime
is0x0000_0001_0000_0000
. - We read the bottom 32 bits, 0x0000_0000.
- We save this into our register t1.
- We check the time in t0:t1, it’s 0x0000_0000_0000_0000!
This is one of the problems with bare-metal programming, we are often communicating with hardware devices that are operating asynchronously to out software.
What can we do to deal with this? The upper bytes in mtime
are very unlikely to change from read to read, so we can loop while there is a difference between reads. As the variable is marked volatile
the compiler knows to keep reading it from "memory" each time. (The one acceptable use of volatile
in C++...)
There are similar issues writing to mtimecmp
that can cause spurious interrupts. Fortunately, the RISC-V spec gives us an example of the code required to avoid this issue ..... in RISC-V assembly.